iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 11
6

定義目標

有匿名者指定說他也會逛 PTT,最但最常逛表特版,所以詢問看看是否能夠自幹表特版 api。我想這種需求看起來挺強烈的,這麼造福廣大男性朋友的 api 肯定是需要的,那我們這次的目標就來抓取 ptt 表特版最新的圖片並將他製作成 api 吧!

話說我平常真的不太逛表特版,所以對裡面的東西並不是太熟悉...但就觀察的結果,每篇文章基本上都會貼上圖片的網址,大概 99% 使用的 imgur 這個圖床,所以這次主題我們就專注在 imgur 的圖片上吧。


實際探訪

進入表特版網址 https://www.ptt.cc/bbs/Beauty/index.html,我們可以看到分頁,然後每個分頁最多是 20 篇文章。觀察一下上頁的連結是 /bbs/Beauty/index2339.html,猜測目前這一頁應該是 /bbs/Beauty/index2340.html,點擊上頁可以看到更多文章,同時也確認剛剛的首頁確實是 /bbs/Beauty/index2340.html,那也就是說我可以抓到 2340 個分頁。

若點擊文章網址,就會進入文章頁面,裡面大部分是一堆 imgur 網址的圖片,若我們能把每一篇文章的圖片位置都抓下來,那麼就能製作出我們的 api 了。

不過在進入研究之前,我們必須先考慮一個問題,若我們真的抓了 2340 個分頁,這麼一來肯定會抓很久,二來絕對會造成伺服器的負擔,這並不是我們的本意,我們的目標只是抓取最新的圖片,但最新如何定義呢?那我們姑且就先定義為最後三個分頁。


分解研究

確認我們的目標是抓前三個分頁以後的所有文章內 imgur 的連結後,我們可以把步驟分成三個動作:

  1. 取得前三頁所有文章列表
  2. 抓取所有文章列表的文章內容
  3. 抓出文章內 imgur 相關的 url

取得每個分頁文章列表

從 dom 的結構看上頁大概就能推算我們要抓取這三個分頁的 url,接著我們來試著 select 頁面上的文章列表。

抓取分頁及文章頁面

接著來 request 一下看看是否能夠確定抓取分頁,然後再 request 測試一下文章頁面,看起來單純的 get request 應該就沒問題了。

測試文章內 imgur 連結

最後我們來測試使用 regex 來抓取 imgur 的連結,在這個部分有注意到 imgur 的連結格式有兩種,但可以歸納只需抓取類似 imgur.com/APpvLiF 這種格式就可以了,後續手動補上完整網址就好。


實作程式碼

getTopPages function

首先我們先來抓前三個分頁,基本上就是 index.html,然後去看上頁的 url,然後上上頁就是將這個 url 的數字部分再減一,也就是說,我們只需要抓上頁的 url 就能清楚整個數列,最後再把這三個頁面的數字部分傳給 callback。

function getTopPages(callback) {
  request('https://www.ptt.cc/bbs/Beauty/index.html', (err, res, body) => {
    var $ = cheerio.load(body)
    var prev = $('.btn-group-paging a').eq(1).attr('href').match(/\d+/)[0]
    callback(['', prev, prev - 1])
  })
}

getPosts function

接著我們來抓取每個分頁的文章列表,基本上沒有什麼難度,單純 select,然後把我們抓到的 posts 丟給 callback。

function getPosts(page, callback) {
  request(`https://www.ptt.cc/bbs/Beauty/index${page}.html`, (err, res, body) => {
    var $ = cheerio.load(body)
    var posts = $('.r-ent a').map((index, obj) => {
      return $(obj).attr('href')
    }).get()
    callback(posts)
  })
}

getImages function

最後我們來抓取每篇文章內的圖片連結,因為觀察到所有的 imgur 圖片的 ID 都是 7個字串,那麼我們使用 regex 來 match /imgur.com\/[0-9a-zA-Z]{7}/g 就行了。不過因為 ptt 網頁會自動加載圖片,所以抓取時會抓到重複的 image url,所以我們必須做 array uniq,可以使用 es6 的語法 [ ...new Set(images) ] 就能簡潔的處理掉重複的 array content。

function getImages(post, callback) {
  request('https://www.ptt.cc' + post, (err, res, body) => {
    var images = body.match(/imgur.com\/[0-9a-zA-Z]{7}/g);
    images = [ ...new Set(images) ]
    callback(images);
  })
}

組合流程

我們先 call getTopPages function 抓取三個分頁,然後再使用 async.map 來跑 getPosts function,去抓取這三個分頁的所有文章列表。抓完文章列表後,我們再使用 async.map 來跑 getImages function,去抓取每篇文章內的 images url,最後再將 images url 補完前綴和後綴,這樣就能抓到最新的所有圖片列表了。

getTopPages((pages) => {
    async.map(pages, (page, callback) => {
      getPosts(page, (posts) => {
        callback(null, posts)
      })
    }, (err, results) => {
      var posts = [].concat.apply([], results)
      async.map(posts, (post, callback) => {
        getImages(post, (images) => {
          callback(null, images)
        })
      }, (err, results) => {
        var images = [].concat.apply([], results).map((image)=>{
          return 'https://' + image + '.jpg'
        })
        console.log(images)
      })
    })
})

Express 做 api service

接下來我們用 express 來做 web service,先暫時聽 3000 port,未來 deploy 再改 80,經過測試沒問題就大功告成了。

const express = require('express');
const app = express();

app.get('/', function (req, res) {
  getTopPages((pages) => {
    async.map(pages, (page, callback) => {
      getPosts(page, (posts) => {
        callback(null, posts)
      })
    }, (err, results) => {
      var posts = [].concat.apply([], results)
      async.map(posts, (post, callback) => {
        getImages(post, (images) => {
          callback(null, images)
        })
      }, (err, results) => {
        var images = [].concat.apply([], results).map((image)=>{
          return 'https://' + image + '.jpg'
        })
        res.json(images)
      })
    })
  })
}).listen(3000)

完整程式碼

const request = require('request');
const cheerio = require('cheerio');
const async = require('async');
const express = require('express');
const app = express();

app.get('/', function (req, res) {
  getTopPages((pages) => {
    async.map(pages, (page, callback) => {
      getPosts(page, (posts) => {
        callback(null, posts)
      })
    }, (err, results) => {
      var posts = [].concat.apply([], results)
      async.map(posts, (post, callback) => {
        getImages(post, (images) => {
          callback(null, images)
        })
      }, (err, results) => {
        var images = [].concat.apply([], results).map((image)=>{
          return 'https://' + image + '.jpg'
        })
        res.json(images)
      })
    })
  })
}).listen(3000)

function getTopPages(callback) {
  request('https://www.ptt.cc/bbs/Beauty/index.html', (err, res, body) => {
    var $ = cheerio.load(body)
    var prev = $('.btn-group-paging a').eq(1).attr('href').match(/\d+/)[0]
    callback(['', prev, prev - 1])
  })
}

function getPosts(page, callback) {
  request(`https://www.ptt.cc/bbs/Beauty/index${page}.html`, (err, res, body) => {
    var $ = cheerio.load(body)
    var posts = $('.r-ent a').map((index, obj) => {
      return $(obj).attr('href')
    }).get()
    callback(posts)
  })
}

function getImages(post, callback) {
  request('https://www.ptt.cc' + post, (err, res, body) => {
    var images = body.match(/imgur.com\/[0-9a-zA-Z]{7}/g);
    images = [ ...new Set(images) ]
    callback(images);
  })
}

衍伸應用

在這個主題,我們其實有兩個假設性的設定,第一個是我們定義了最新圖片為前三個分頁,若更近一步的開發,其實是可以在 api 的參數收 page number,交由使用者來決定他要抓幾個分頁,但這個就必須考量到爬蟲的速度,和 ptt web server 的防堵機制,總不可能放任使用者去抓所有的分頁,不然可是有很大的機會會被直接鎖 IP 的。

另外一個是我們只抓 imgur 的圖片連結,但現實上是有可能出現其他種類的圖片網址的,關於這點,其實我們無法把所有狀況含括,可以有幾種想法,例如我們去判斷副檔名,但這也不見得是百分之百,或者我們去讀取 ptt web server 所附帶的圖片解析器,這或許比較靠譜,不過這就另外的課題了。


上一篇
PTT 台南版置底 3C 文
下一篇
591 出租網爬蟲
系列文
爬蟲始終來自於墮性34
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
海綿寶寶
iT邦大神 1 級 ‧ 2017-12-14 09:34:20

這篇讓我聯想起多年前的 diggirl 和幹圖王

順手 Google 了一下作者獨孤木
專案管理界有名的作者
才突然發現他今年八月份過世了
一代技術強者
令人感慨萬千呀
R.I.P.
/images/emoticon/emoticon10.gif

挖出一篇獨孤木參與的古文來回味一下
有沒有不會寫程式卻在做系統分析的?
原本是個沒事拿來閒嗑牙的問題
後來卻引發跨越幾年、幾百篇的大戰
/images/emoticon/emoticon06.gif

Howard iT邦新手 4 級 ‧ 2017-12-14 10:34:25 檢舉

孤獨木
R.I.P.

小弟在 10 年前還是個萌懂的小鬼
所以沒有參與到這百篇大戰
但這個主題縱使到了今日,我想還是可以有百篇的論戰點
要不要我們也來開一篇大家回味一下

Howard iT邦新手 4 級 ‧ 2017-12-14 10:48:48 檢舉

我試著把這百篇討論串看完
真的是好文!!

0
01509
iT邦新手 5 級 ‧ 2018-02-15 07:52:00

請問前輩

可以示範如何大量將imgur的圖片,下載下來嗎?
小弟照著網路上的教學做,貌似是流量太大
就會被imgur Ban掉,不知道前輩能否提點下呢.

謝謝.

Howard iT邦新手 4 級 ‧ 2018-02-15 10:47:38 檢舉

基本上被 ban 那是肯定會的
而方法其實比較被動
要嘛就是拉長抓取的間隔時間
要嘛就換 IP 抓

我要留言

立即登入留言